Docker 基础(二)- Dockerfile
理论知识
关于 Dockerfile 的详细使用,请参考 Dockerfile reference 说明。
Dockerfile 的本质
Dockerfile 是一个文本格式的镜像构建蓝图。它由一系列命令和参数组成,每条指令构建一层,最终叠加形成一个只读的 Docker 镜像。它的核心底层是联合文件系统(UnionFS)与镜像层(Layers)。
- Dockerfile 中的很多指令(如 RUN、COPY、ADD)都会创建一个新的镜像层(Layer)。
- 镜像层是只读的、可复用的。
- 当容器启动时,Docker 会在这些只读层的顶端添加一个可读写层(Container Layer)。
- 分层机制的好处:
- 极其节省空间:如果多个镜像基础层相同,它们在宿主机上只会存储一份。
- 极大提升构建速度:未改变的层会直接触发构建缓存(Build Cache),无需重新下载或计算。
常用的关键字
- FROM:指定新镜像的基础镜像。Dockerfile 必须以 FROM 开头(除非前面有 ARG 变量)。在多阶段构建中,一个 Dockerfile 可以出现多个 FROM,用以分离编译环境和运行环境。
- MAINTAINER:声明镜像的作者/维护者信息。这个关键字在旧版本很常用,但现在已经被官方废弃了!现在推荐使用通用的 LABEL 指令来代替它,例如:LABEL maintainer=”owlias “。
- RUN:在 docker build(镜像构建)阶段执行的命令。通常用于执行 apt-get install、mvn clean package 等去搭建基础环境。每一个 RUN 指令都会在只读层上叠加新的一层(Layer)。应该尽量使用
&&将多条 Shell 命令合并成一个 RUN,并在命令末尾顺手清理掉安装包缓存(如 rm -rf /var/cache/apk/*),以追求极致的镜像瘦身。 - EXPOSE:声明容器运行时打算监听的端口。它仅仅是一个声明(留给运维或微服务编排工具K8s看的),并不会自动在宿主机上做端口映射。如果想真正映射端口,启动容器时还是要老老实实用
-p。 - WORKDIR:为后续的 RUN、CMD、ENTRYPOINT、COPY、ADD 指令设置工作目录(落脚点)。
- USER:指定运行后续命令以及容器启动时的用户名或 UID。默认情况下容器是用 root 用户运行的,这存在极大的容器逃逸安全隐患。标准的安全做法是在构建后期先通过 RUN useradd 创建一个低权限的普通用户,然后通过 USER myuser 切换,确保容器进程以非 root 身份在生产环境运行。
- ENV:设置环境变量。这些变量在镜像构建过程中以及容器运行时都持久有效。后续指令可以直接通过 “$变量名” 来引用它。
- ADD:COPY 的增强版,具备两个特殊超能力,即如果源文件是宿主机的本地压缩包(如 .tar.gz),复制到容器内时会自动帮你解压成目录;另一方面它也支持远程 URL。
- COPY:纯粹地、规规矩矩地复制本地文件或目录。生产环境无脑推荐使用,含义最单一、最安全。
- VOLUME:在容器内创建一个匿名数据卷挂载点。它的核心目的是防止用户忘记挂载目录,导致持久化数据(如 MySQL 数据、Redis AOF 文件)随容器销毁而丢失。
- 在 Dockerfile 中声明 VOLUME [“/data”] 后,即使启动容器时没加
-v参数,Docker 也会自动在宿主机的/var/lib/docker/volumes/下生成一个随机名字的文件夹与之绑定,强行保障数据安全。 - 如果 Dockerfile 指定了 VOLUME,而 docker run 时又指定了 -v(具名挂载),两者的路径如果发生冲突,docker run -v 会以绝对优势强行覆盖 Dockerfile 中的声明。
- 例如当你执行 docker run -v /host/my_data:/data,结果是 Dockerfile 里的匿名卷声明直接失效,Docker 会直接把宿主机的 /host/my_data 目录绑定到容器的 /data,此时绝对不会在宿主机的 /var/lib/docker/volumes/ 下生成那个乱码一样的匿名卷文件夹。
- 又比如你执行 docker run -v my_named_vol:/data,结果是 Dockerfile 里的匿名卷声明同样直接失效,Docker 会在宿主机创建一个名字叫 my_named_vol 的干净卷,并把它挂载到容器的 /data,而不会产生任何匿名垃圾。
- 如果你在 Dockerfile 里写了 VOLUME [“/data”],但你启动时因为疏忽,敲成了:docker run -v /host/my_data:/app_data my-image(路径写错了,把 /data 敲成了 /app_data)。Docker 依然会在你的宿主机底层偷偷创建一个无名的匿名卷挂载到 /data。随着你容器不停地重启、销毁、重建,宿主机的 /var/lib/docker/volumes/ 下就会积压大量几百兆甚至几个G的无名孤儿文件夹,直到某天把你的服务器磁盘彻底撑爆。这时候生产环境定期执行
docker volume prune,这个命令会一枪放倒所有由于这种“擦肩而过”产生的、目前没有被任何容器使用的匿名垃圾卷。
- 在 Dockerfile 中声明 VOLUME [“/data”] 后,即使启动容器时没加
- CMD:提供容器启动的默认命令及参数。它的特点是极易被覆盖。如果在 docker run 后面随便加了任何参数(如 docker run my-image /bin/bash),Dockerfile 里的 CMD 会被直接无情覆盖,完全不执行。
- ENTRYPOINT:让容器像一个普通的二进制可执行文件一样运行,其参数很难被覆盖。它的特点是 docker run 后面附带的任何额外参数,都会被当成附加参数追加(Append)到 ENTRYPOINT 命令的尾部。
RUN、CMD、ENTRYPOINT
RUN:在镜像构建阶段(docker build)执行的命令。通常用于安装软件、配置环境、创建目录。它会生成新的镜像层。
CMD:在容器启动阶段(docker run)执行的默认命令。Dockerfile 中只能有一条 CMD,如果写了多条,只有最后一条生效。在实际的使用中,CMD 极易被 docker run 后面附带的参数直接覆盖。
ENTRYPOINT:也是在容器启动时执行的命令,但它不会被轻易覆盖。在实际生产中,通常用 ENTRYPOINT 指定容器的启动主体程序,用 CMD 充当其默认参数。例如:
1
2ENTRYPOINT ["redis-server"]
CMD ["/etc/redis/redis.conf"]当用户执行 docker run my-redis –port 6380 时,–port 6380 会覆盖 CMD 传递给 ENTRYPOINT,变成 redis-server –port 6380 启动,极其优雅。
COPY、ADD
- COPY:纯粹地将宿主机的本地文件/目录复制到镜像内。功能单一,但高效且推荐。
- ADD:COPY 的增强版。
- 它支持自动解压:如果源文件是个本地压缩包(.tar.gz),它会自动帮你在容器内解压。
- 它支持远程 URL:可以直接去下载网络文件(但不推荐,因为会产生多余的下载缓存层)。
- 生产环境无脑推荐 COPY,除非你明确需要本地压缩包自动解压的功能。
编写高水准的 Dockerfile
第一,采用多阶段构建(Multi-Stage Builds)—— 核心减重神技
以 Java 为例,编译源码需要 Maven/JDK,但运行只需要 JRE。如果把 Maven 和源码都留在镜像里,镜像动辄 1GB。
实际可以在同一个 Dockerfile 中定义多个 FROM,前一个阶段负责编译,后一个阶段只把编译好的产物(如 .jar)通过 COPY –from 复制过来。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15# 第一阶段:编译构建
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
# 第二阶段:最小化运行
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
# 隔空取物:只把第一阶段的 jar 包偷过来,抛弃所有编译依赖
COPY --from=builder /build/target/mall-service.jar ./app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
第二,精明利用构建缓存(Cache)
- Docker 的缓存机制是:一旦某一层由于代码改变导致缓存失效,它后面的所有层缓存将全部报废。
- 坏榜样:先 COPY . .(复制整份源码),再 RUN mvn install。这样只要你改了一行 Java 代码,Maven 依赖层就会被迫全部重新下载,慢到崩溃。
- 好榜样:先仅仅 “COPY pom.xml .”,然后 RUN mvn dependency:go-offline(先下载好依赖并形成固定的镜像层),最后再 “COPY src ./src” 编译。这样只要 pom.xml 没变,依赖层永远走缓存,构建速度从 10 分钟飙升到 10 秒。
第三,合并 RUN 指令,清理多余残骸
- 每一个 RUN 都会增加一层。应该用
&&将多条 Shell 命令合并成一条。 - 并在同一层命令的末尾,顺手清理缓存和临时文件(如 rm -rf /var/cache/apk/* 或 apt-get clean),否则这些垃圾数据会被永久固化在只读层里,即便后续用新指令删除也无法缩减镜像体积。
- 每一个 RUN 都会增加一层。应该用
第四,选用精简的基础镜像(Base Image)
- 拒绝盲目使用 ubuntu 或 centos 作为底包。
- 优先选用 Alpine(一个只有 5MB 左右的极其精简的安全 Linux 发行版)或者 Slim 版本(瘦身版)。
第五,拒绝使用 Root 用户运行
- 默认情况下,容器内部的进程是用 root 运行的。一旦容器被黑客攻破且发生逃逸,宿主机将直接沦陷。应在 Dockerfile 末尾使用 RUN useradd -m myuser && USER myuser 切换为低权限普通用户。
第六,显式声明端口和环境变量
- 用 EXPOSE 和 ENV 明确暴露微服务的边界,方便业务网关和微服务编排软件进行健康检查与服务发现。
简单案例
centos7+vim+ifconfig+jdk8
第一步:准备 Dockerfile
vim ./Dockerfile
1 | # 1. 指定基础镜像:经典的 CentOS 7 |
第二步:构建镜像
在终端中切换到 Dockerfile 所在的目录下,执行以下构建命令(注意末尾有一个 . 代表当前路径):
1 | $ docker build -t my-centos7-java8-dev:v1.0 . |
第三步:启动容器
镜像构建成功后,我们直接启动一个容器,并切入交互式终端(-it)来检验我们的定制成果:
1 | $ docker run -it --name mycentos7-java8-dev my-centos7-java8-dev:v1.0 |
连入容器后,你会发现你直接站在了 /root(WORKDIR 指定的家目录)下。接下来依次敲入以下命令进行验证:
1 | # 验证 ifconfig 网络工具 |
发布微服务到docker容器
1 | $ docker images |
Dockerfile:
1 | FROM my-centos7-java17-dev:v1.0 |
构建镜像:
1 | $ docker build -t my-centos7-java17-app:v1.0 . |
启动容器:
1 | # 启动和查看 |
关于日志的查看:
1 | # 实时跟踪打印这个容器的 Spring Boot 日志(类似 tail -f) |